Skip to content

release v1.41.0 DEV to main promotion - #2911

Merged
jung-thomas merged 55 commits into
mainfrom
release/v1.41.0
Oct 24, 2026
Merged

jung-thomas merged 55 commits into
mainfrom
release/v1.41.0

Conversation

@jung-thomas

Copy link
Copy Markdown
Contributor

Release 1.41.0 — DEV → main promotion

Minor release promoting the fixes and features merged to DEV since 1.40.4 to main for a PROD deploy.

Version bumps

MTA File Change
Main (tutorials-ims) .deploy/mta.yaml 1.40.4 → 1.41.0
MCP (tutorials-mcp) mta-mcp.yaml 1.0.4 → 1.1.0

Minor bump: this window includes new features (App Space parallel paths, badge-scanner v2, temporarily-unavailable items in the kiosk).

Changes in this window (18 whats-new entries)

Features

Fixes

Maintenance

Deploy

Merging this PR does not redeploy — a separate MTA deploy is required (main MTA + the separate tutorials-mcp MTA). No deploy authorization implied by this PR.

🤖 Generated with Claude Code

The per-path-item temporarily-unavailable flag (admin-toggled, already honored
on the mission page) was dropped by the App Space data path, so a disabled
item still rendered as an active, clickable kiosk card.

- path-item-task-resolver: shapeItem/buildPathItems now carry
  temporarilyUnavailable + unavailableReason on direct items (group-expanded
  children stamped false, matching the mission page).
- app-space-gating: isTutorialOpenable returns false for unavailable items —
  the single chokepoint that both blocks the click and strips .clickable.
- AppSpace.vue: 'Temporarily Unavailable' badge + reason, greyed/delinked card.

Tests: buildPathItems passthrough, gating block, and a mounted-component test
proving badge render + no window.open on click.
…sable

feat(#2864): honor temporarily-unavailable path items in App Space
…usters)

Models parallelism within a track entirely on the existing schema:
shared itemOrder = parallel; 2+ GROUP rows at one Order = side-by-side
branch lanes; N TUTORIAL rows at one Order = free-order cluster.
Per-lane reward via GROUP+prize on the same CompletionPathItems row
(resolver appends a synthetic CHECKPOINT at read time); join reward via
a downstream CHECKPOINT. No schema change.
Add test/e2e/app-space-parallel.spec.ts: post-deploy smoke spec that
loads /app-space/ and, when a fork track is seeded on the env, asserts
two .lane columns in the fork segment, a .lane__name label on each, and
at least one .step--locked downstream item (the join checkpoint locked
for anonymous visitors).  Self-skips without SMOKE_BASE_URL (repo e2e
convention, #1338).

Admin editor verification (Part A, read-only):
- itemOrder has no @assert.unique constraint on CompletionPathItems
  (db/schema.cds) and is an editable integer column with no uniqueness
  validator in the Fiori Elements table — duplicate Order values for
  parallel rows are freely settable.
- prize_ID has no conditional @UI.Hidden tied to taskType; the Prize
  column is visible for every row type including GROUP items — group +
  prize authoring on one row is fully supported.
No admin code change required.
…llel-design

# Conflicts:
#	hugo-apps/src/app-space/AppSpace.vue
…l-design

App Space: parallel paths within a track (fork lanes + free-order clusters) (#2887)
…hildren

#2888's buildPathSegments hardcoded temporarilyUnavailable:false on
GROUP-expanded children in both the lone-GROUP linear branch and the
fork-lane branch (finding M2 / documented limitation). A temporarily-
unavailable GROUP branch therefore stayed navigable and, if its children
were previously COMPLETED, its lane could spuriously earn the prize +
downstream unlock.

Propagate the parent GROUP path-item's temporarilyUnavailable and
unavailableReason onto each expanded child via shapeItem's existing
`unavail` arg (both the lone-GROUP linear path and the fork-lane GROUP
children). This fixes both consequences with no client-logic change and
no schema change:
 - render: AppSpace.vue laneUnavailable + the linear timeline already
   read the per-item flag, so the branch now dims;
 - completion-counting: isComplete()/itemDone() return false for an
   unavailable item, so isLaneComplete/isLaneTutorialsComplete no longer
   mark an unavailable lane complete.

getEventProgress / buildPathItems (linear tracks) still pass null for
GROUP children, so that path keeps its prior non-inherited behavior.
The synthetic lane-prize CHECKPOINT stays available=false: an
unavailable lane never completes, so its prize cannot unlock anyway.

Updated the now-stale shapeItem and AppSpace.vue laneUnavailable
comments that described the old non-propagation behavior.

Tests: new buildPathSegments unit cases (lone-GROUP linear, fork-lane,
available-control) in test/unit/app-space-segments.test.js.
…ilable-propagation

fix(#2890): propagate GROUP temporarilyUnavailable to expanded lane children
App Space `isTrackComplete` marked a segmented track complete when ANY
checkpoint unlocked (`checkpoints.some(isSegmentItemUnlocked)`). For a
hypothetical track with a non-terminal plain CHECKPOINT (authored at a
lower order than later tutorials), that early checkpoint unlocking would
flag the whole track complete while downstream work remained.

Make the rule prize-aware instead of a naive highest-order restriction:
- lane-prize checkpoints (prizeId set) are authored to earn the reward
  the moment their single lane finishes → ANY unlocked one completes the
  track (keeps `some` semantics);
- plain downstream checkpoints (no prizeId) are joins → only the TERMINAL
  (highest-order) one completes the track.

Add client unit tests for isTrackComplete/progressPercent over fork
payloads (the aggregate rule was previously only exercised indirectly via
the barcode tests): the non-terminal-checkpoint bug guard fails against
the old `some()` rule, and a lane-prize-only payload guards against an
over-eager highest-order-only regression.

Follow-up to #2887 (PR #2888), finding M1.
…st-checkpoint

fix(#2889): key isTrackComplete off the highest-order checkpoint
A PROD deploy on 2026-10-21 shipped the approuter built from an
uncommitted working-tree edit to hugo/assets/css/sap-fundamental.css, so
Hugo fingerprinted the dirty CSS and the approuter served a hash the
content pipeline (built from committed origin/main) never bakes -> 404
on every tutorial on /tutorials/ and /tutorials-qa/ (PROD-down).

guardPrimaryTreeBranch (Step 0) missed it: a dirty tree keeps the same
HEAD SHA, so both the PROD (branch) and DEV/QA (HEAD===origin SHA) paths
pass with a dirty hugo/assets/** file.

Add deploy-mta.cjs Step 0.5 (guardCleanBuildInputs, all envs, before
build:all): git status --porcelain -- hugo/assets hugo/layouts, die(1)
listing any tracked-modified build input. Pure parser dirtyBuildInputs
exported + unit-tested; ignores untracked so build:all-generated
hugo/data/*.json never false-positives (scope excludes hugo/data).
Read-only -> runs under --dry-run and --skip-build.

Doc the regression in tutorials-ims-gotchas.md + CLAUDE.md pointer.
Code-review minor: the gate only blocks TRACKED changes, so plain stash
of the file suffices; -u also stashes untracked (broader than needed and
at odds with the repo's multi-worktree bare-stash warning).
…gate

fix(#2886): refuse deploy when Hugo fingerprint inputs are dirty
…et-gate

fix(#2885): gate content publish on approuter-served CSS/JS assets (QA + CLI)
The admin PAT scope (headless /admin-pat/* + /graphql-pat, #2574) is
validated and enforced end-to-end in the backend, but the mint dialog
only offered read/write — so a user following the docs could never mint
an admin-scoped PAT and GET /admin-pat/* 403'd with 'lacking required
roles: [Admin]'.

- srv/pat-service.{cds,js}: add unbound PatService.patCapabilities()
  returning {adminScopeAvailable} = PAT_ADMIN_SCOPE_ENABLED AND
  req.user.is('Admin'), mirroring the handleMintPAT gate.
- MintDialog.fragment.xml + PatActionsController.js: the admin scope item
  (and its hint) are hidden by default and shown only when
  patCapabilities reports the caller may mint one; fails closed on error.
- docs: clarify the admin-scope gate in api-consumption.md; add a
  regression note to tutorials-ims-gotchas.md (don't make the admin item
  statically visible — reintroduces the non-admin 403).
- test/pat-admin-grant.test.js: cover patCapabilities (flag+admin -> true,
  non-admin -> false, flag off -> false).

Backend gates, AdminGrants, feature flag, approuter routes and TTL clamp
are unchanged.
…-pat-scope-ui

fix(#2857): surface admin PAT scope in mint UI (gated to admins)
…of hard-fail

An admin-write save to a Tutorials row whose slug was retired from the
source repos dispatches a slug-targeted rebuild for that slug. fetch-tutorials
then hard-fails (process.exit 1) because discovery no longer returns it,
aborting the whole run and firing a false #1373 channel alert.

Distinguish auto-dispatched runs (admin-write/scheduled/admin-action) from
human manual dispatches via the trigger-source input: on an all-unknown
(fatal) filter, auto-dispatched runs exit 0 (nothing to publish) while manual
runs keep the #433 author-typo fail-fast. Wire trigger-source into the fetch
step env; extract isAutoDispatchedTrigger() with unit tests.
… built)

Records the parallel-any 'any one -> one shared prize' exploration and why it
was deferred: themed fork lanes with per-lane prizes (shipped #2887/#2889) plus
the server-side max-2 prize cap cover the requirement, so no shared-prize code
change is needed. Kept for a future event that genuinely needs any-one-of-N ->
one shared prize.
)

The fork drop and join up-stub connectors were positioned at each lane
COLUMN center ((li+0.5)/N*100%), but a lane's bubble sits a fixed ~1.8rem
from its column's left edge (1px border + 0.6rem padding + 1.125rem
half-bubble), not at the column center. The gap scaled with column width,
so on 2-lane forks the vertical connectors landed ~200px right of the
bubbles they should touch (measured in a browser repro).

Fix: position drops/ups at calc((100% - (N-1)rem)/N*li + li*rem + 1.8rem)
via a laneBubbleLeft() helper; span both bars from lane-0 to lane-(N-1);
drop the fork-connector's left margin so its origin matches .lanes; anchor
the fork stem + join down-stub to the backbone bubble center (1.175rem).
Verified <=1px alignment across lane counts/widths in a static repro.
Class names/structure unchanged — AppSpace.segments.test.ts still passes.
…el-any-prize

fix(app-space): align fork/join connectors to lane bubble centers (#2896)
…noop

fix(rebuild): no-op auto-dispatched rebuild for retired slug instead of hard-fail
…n fingerprint

Root cause of the recurring post-deploy "approuter does NOT serve referenced
CSS" guard failures (and the stale/unstyled tutorial pages before the guard
existed): `sap-fundamental.css` (generated by `build:css`, postcss) and
`fundamental-styles-icon.css` (generated by `build:icon-subset`) were BUILD
OUTPUTS committed to git. The committed bytes had drifted from what the build
now emits:

  committed sap-fundamental: 7f01bb57…   fresh build:css: 9dbb07c7…
  committed icon:            b34fb316…   fresh build:icon: 317e9916…

A deploy runs `build:all` → regenerates both → fingerprint A. rebuild-content.yml
drives `/tmp/hugo` directly (NOT `build:all`) → used the STALE committed copy →
fingerprint B. A ≠ B → the approuter-asset guard correctly blocked publish.
Both generators are deterministic (verified: two runs → identical bytes), so the
only variable was the stale committed artifact.

Fix — make both publish paths generate from source, never from git:
  * .gitignore + `git rm --cached` both generated CSS files.
  * rebuild-content.yml: add a "Build fingerprinted CSS" step
    (build:icon-subset + build:css) BEFORE the Hugo build, mirroring the
    existing "explicit step rather than build:all" fetch steps. npm ci already
    installs fundamental-styles, which build:icon-subset needs.
  * Deploy path unchanged — build:all already runs both before mbt build.

Interaction with #2886: deploy-mta.cjs guardCleanBuildInputs (dirty-tree skew
guard) is generic and skips untracked/ignored paths, so it no longer watches
these outputs — and no longer needs to. Its real job (protect committed SOURCES
in hugo/assets + hugo/layouts) is intact; this change removes the committed
generated output entirely, so it CANNOT be dirty — a stronger guarantee than the
guard alone.

NOTE: rebuild-content.yml's auto-trigger runs off `main`, so this workflow
change only takes effect for auto-rebuilds once promoted DEV→main.
…s-drift

fix(build): stop committing generated CSS so deploy and rebuild agree on fingerprint
…low-up)

PR #2899 aligned the fork drops/ups to each lane's bubble center, but left the
horizontal bar starting at lane-0's bubble (~61px) while the vertical stem sits
at the backbone bubble center (1.175rem ≈ 51px) — a ~10px horizontal jog where
the stem meets the bar (visible at the top of the fork in the CAP track).

Measured on the live DEV page (event 10000015, CAP track): stem left 50.8,
bar left 60.8 → 10px gap. Setting the bar's left to 1.175rem (the stem x)
closes it: re-measured gap_stem_to_bar = 0. Verified against the real rendered
DOM, not a static repro. Same change applied to the join-connector bar for
tracks that have a downstream join.
…r-gap

fix(app-space): connect fork/join bar to the backbone stem (#2896 follow-up)
…, claimed names, event stats

Probed DEV HANA (read-only): all 14,686 CLAIMED PrizeRecords carry a real
prize name (0 null); statuses are CLAIMED/EARNED only; 16-row Prizes catalog.
Folds builder-check resolutions in as calls. Awaiting approval before build.
…Contestant action (#badge-v2)

- getContestant completion counts now scoped to the scanned event (match the
  event-stats panel; falls back to all-events when eventId absent/unresolvable)
- claimPrizeForContestant invoked via POST with body params (it is an OData
  action, not a function) — fixes the pre-existing GET-vs-action mismatch
fix(scanner): badge-scanner v2 — camera, real prize count, identity, claimed prizes, event stats (#badge-v2)
A `<MyApp />` inside a backtick code span was being double-escaped to
the literal text `&lt;MyApp /&gt;` on the rendered page.

Root cause: sanitizeLine (stripDangerousHtml) runs on raw markdown
line-by-line and pseudo-tag-escapes any unknown tag-like token to
`&lt;...&gt;` — including tokens inside inline code spans. Goldmark then
renders the backtick span as <code> and HTML-escapes the `&` a second
time, so `<` becomes `&lt;` becomes the visible literal `&lt;`.

Fix: stash inline code spans (`...`, ``...``) before pseudo-tag/sanitize
processing and restore them verbatim at the end, mirroring the existing
autolink-stash pattern. Stashing happens before autolink extraction so a
`<url>` inside a span stays literal. Pseudo-tags OUTSIDE code spans are
still escaped as before.

- 3 new unit tests (inline span, mixed line, double-backtick span)
- catalog smoke classifier taught the same code-span exception
- verified 0 regressions across 1498 corpus tutorials

Fixes the JSX tip rendering reported on the React tutorials.
…udotag-escape

fix(parser): preserve pseudo-tags inside inline code spans
…outer

Root cause (verified live via Playwright on DEV):
- All contestant> bindings were relative (claimedCount) not absolute
  (/claimedCount). With no binding context on the contestant JSONModel,
  they resolved to null -> prizeCount rendered 'null of 2' while the
  model held 0; identity/stats blank; visible-state expressions wrong.
  Injecting the absolute path on the live control: 'null of 2' -> '0 of 2'
  -> '1 of 2'.
- manifest declared the App view as BOTH rootView AND routing target
  'main' (same id) -> 'adding element with duplicate id container-scanner---App'
  throw on router init. App has a single screen; dropped the router and
  its Component.js getRouter().initialize() call.

Untouched by #2902 (badge-scanner v2 only covered the Vue island + backend).
Root cause (confirmed live on DEV): useBarcodeScanner gated isSupported on
'BarcodeDetector' in window. That API is Chromium/Android-only — absent on
iOS Safari AND desktop Edge/Chrome in this deployment — so every such browser
fell through to showManualInput ('Camera scanning not available') despite
having a working camera (getUserMedia present, secure context).

- isSupported now = getUserMedia available in a secure context
  (extracted to camera-support.ts, unit-tested).
- Scan loop uses native BarcodeDetector when present, else decodes
  getUserMedia frames with jsQR (new dep, ~15KB) over a canvas.
- Manual-paste path retained as the fallback for no-camera/insecure cases.

Tests: hugo-apps/src/scanner-vue/__tests__/camera-support.test.ts (8 pass).
Follow-up to #2902 (badge-scanner v2), which shipped the BarcodeDetector-only gate.
…-apps

fix(scanner): repair both scanner apps (null of 2 + camera not available)
The admin PAT scope was wired into app/admin/pats/ (the Fiori admin PATs
list, #2857) — but users mint tokens from /me/tokens/, which is the Vue
island hugo-apps/src/tokens/ApiTokens.vue, a DIFFERENT app. Its Access
dropdown was hardcoded to read / readwrite with no admin option and no flag
check, so flipping PAT_ADMIN_SCOPE_ENABLED had no visible effect on the page
users actually use.

- ApiTokens.vue: probe PatService.patCapabilities() on mount; show an 'admin'
  <option> only when adminScopeAvailable (flag ON AND live Admin role).
  onMint maps scope 'admin' → scopes:['admin']. Probe is shape-tolerant and
  fails CLOSED (option hidden on any error/non-JSON).
- tests: admin option hidden when unavailable / on probe error; shown when
  available; mint sends scopes:['admin']. 12/12 island specs pass.

Reuses the patCapabilities() backend from #2857 — no backend change.
…dmin-scope

fix(#2574): add admin PAT scope to the /me/tokens/ mint UI (the app users actually use)
…name in UI5

Two independent badge-scanner bugs surfaced while testing the Vue port
against DEV:

1. Vue claim POST got a bare HTTP 403. The approuter enforces CSRF on
   writes to /scanner/* (no csrfProtection:false on that route), and the
   Vue fetch sent no x-csrf-token. The UI5 app works only because its
   sap.ui.model.odata.v4.ODataModel does the fetch-token handshake for
   you. useScannerApi now fetches a token (GET x-csrf-token: fetch),
   sends it on the claim POST, and refetches+retries once on a 403 CSRF
   challenge (stale token). NOT an "already claimed" case — a duplicate
   claim returns HTTP 200 "cap reached (2 of 2)".

2. UI5 scanner showed the raw UID instead of the contestant name. The
   getContestant handler returns lastName/firstInitial (unit-tested), but
   the UI5 controller never read them and the view bound /uid as the
   title. Controller now reads both; view shows "LastName, F." with the
   UID underneath. The Vue app already rendered these correctly.

Tests: scanner-vue claim suite 8/8 (incl. new CSRF handshake + retry
cases); scanner-vue full suite 20/20; server getContestant unit 8/8.
…-and-contestant-fields

fix(scanner): CSRF token on Vue claim POST + contestant name in UI5
@jung-thomas
jung-thomas merged commit d4a7191 into main Oct 24, 2026
6 of 7 checks passed
@jung-thomas
jung-thomas deleted the release/v1.41.0 branch October 24, 2026 10:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant